#!/usr/bin/env ruby
# frozen_string_literal: true

# !!!!!!!
# No gems can be required in this file until we invoke bundler setup except inside the forked process that sets up the
# composed bundle
# !!!!!!!

$stdin.sync = true
$stdout.sync = true
$stderr.sync = true
$stdin.binmode
$stdout.binmode
$stderr.binmode

setup_error = nil
install_error = nil
reboot = false

workspace_uri = ARGV.first
raw_initialize_path = File.join(".ruby-lsp", "raw_initialize")

raw_initialize = if workspace_uri && !workspace_uri.start_with?("--")
  # If there's an argument without `--`, then it's the server asking to compose the bundle and passing to this
  # executable the workspace URI. We can't require gems at this point, so we built a fake initialize request manually
  reboot = true
  "{\"params\":{\"workspaceFolders\":[{\"uri\":\"#{workspace_uri}\"}]}}"
elsif ARGV.include?("--retry")
  # If we're trying to re-boot automatically, we can't try to read the same initialize request again from the pipe. We
  # need to ensure that the retry mechanism always writes the request to a file, so that we can reuse it
  content = File.read(raw_initialize_path)
  File.delete(raw_initialize_path)
  content
else
  # Read the initialize request before even starting the server. We need to do this to figure out the workspace URI.
  # Editors are not required to spawn the language server process on the same directory as the workspace URI, so we need
  # to ensure that we're setting up the bundle in the right place
  headers = $stdin.gets("\r\n\r\n")
  content_length = headers[/Content-Length: (\d+)/i, 1].to_i
  $stdin.read(content_length)
end

# Compose the Ruby LSP bundle in a forked process so that we can require gems without polluting the main process
# `$LOAD_PATH` and `Gem.loaded_specs`. Windows doesn't support forking, so we need a separate path to support it
pid = if Gem.win_platform?
  # Since we can't fork on Windows and spawn won't carry over the existing load paths, we need to explicitly pass that
  # down to the child process or else requiring gems during composing the bundle will fail
  load_path = $LOAD_PATH.flat_map do |path|
    ["-I", File.expand_path(path)]
  end

  Process.spawn(
    Gem.ruby,
    *load_path,
    File.expand_path("../lib/ruby_lsp/scripts/compose_bundle_windows.rb", __dir__),
    raw_initialize,
  )
else
  fork do
    require_relative "../lib/ruby_lsp/scripts/compose_bundle"
    compose(raw_initialize)
  end
end

begin
  # Wait until the composed Bundle is finished
  _, status = Process.wait2(pid)
rescue Errno::ECHILD
  # In theory, the child process can finish before we even get to the wait call, but that is not an error
end

begin
  bundle_env_path = File.join(".ruby-lsp", "bundle_env")
  # We can't require `bundler/setup` because that file prematurely exits the process if setup fails. However, we can't
  # simply require bundler either because the version required might conflict with the one locked in the composed
  # bundle. We need the composed bundle sub-process to inform us of the locked Bundler version, so that we can then
  # activate the right spec and require the exact Bundler version required by the app
  if File.exist?(bundle_env_path)
    env = File.readlines(bundle_env_path).to_h { |line| line.chomp.split("=", 2) }
    ENV.merge!(env)

    if env["BUNDLER_VERSION"]
      Gem::Specification.find_by_name("bundler", env["BUNDLER_VERSION"]).activate
    end

    require "bundler"
    Bundler.ui.level = :silent

    # This Marshal load can only happen after requiring Bundler because it will load a custom error class from Bundler
    # itself. If we try to load before requiring, the class will not be defined and loading will fail
    error_path = File.join(".ruby-lsp", "install_error")
    install_error = begin
      Marshal.load(File.read(error_path)) if File.exist?(error_path)
    rescue ArgumentError
      # The class we tried to load is not defined. This might happen when the user upgrades Bundler and new error
      # classes are introduced or removed
      File.delete(error_path)
      nil
    end

    Bundler.setup
    $stderr.puts("Composed Bundle set up successfully")
  end
rescue Bundler::GemNotFound, Bundler::GitError
  # Sometimes, we successfully set up the bundle, but users either change their Gemfile or uninstall gems from an
  # external process. If there's no install error, but the gem is still not found, then we need to attempt to start from
  # scratch
  unless install_error || ARGV.include?("--retry")
    $stderr.puts("Initial bundle compose succeeded, but Bundler.setup failed. Trying to restart from scratch...")
    File.write(raw_initialize_path, raw_initialize)
    exec(Gem.ruby, __FILE__, *ARGV, "--retry")
  end
rescue StandardError => e
  setup_error = e
  $stderr.puts("Failed to set up composed Bundle\n#{e.full_message}")

  # If Bundler.setup fails, we need to restore the original $LOAD_PATH so that we can still require the Ruby LSP server
  # in degraded mode
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
end

# When performing a lockfile re-boot, this executable is invoked to set up the composed bundle ahead of time. In this
# flow, we are not booting the LSP yet, just checking if the bundle is valid before rebooting
if reboot
  # Use the exit status to signal to the server if composing the bundle succeeded
  exit(install_error || setup_error ? 1 : status&.exitstatus || 0)
end

# Now that the bundle is set up, we can begin actually launching the server. Note that `Bundler.setup` will have already
# configured the load path using the version of the Ruby LSP present in the composed bundle. Do not push any Ruby LSP
# paths into the load path manually or we may end up requiring the wrong version of the gem
require "ruby_lsp/internal"

if ARGV.include?("--debug")
  if ["x64-mingw-ucrt", "x64-mingw32"].include?(RUBY_PLATFORM)
    $stderr.puts "Debugging is not supported on Windows"
  else
    begin
      ENV.delete("RUBY_DEBUG_IRB_CONSOLE")
      require "debug/open_nonstop"
    rescue LoadError
      $stderr.puts("You need to install the debug gem to use the --debug flag")
    end
  end
end

initialize_request = JSON.parse(raw_initialize, symbolize_names: true) if raw_initialize

begin
  server = RubyLsp::Server.new(
    install_error: install_error,
    setup_error: setup_error,
    initialize_request: initialize_request,
  )

  # Ensure all output goes out stderr by default to allow puts/p/pp to work without specifying output device.
  $> = $stderr

  server.start
rescue ArgumentError
  # If the launcher is booting an outdated version of the server, then the initializer doesn't accept a keyword splat
  # and we already read the initialize request from the stdin pipe. In this case, we need to process the initialize
  # request manually and then start the main loop
  server = RubyLsp::Server.new

  # Ensure all output goes out stderr by default to allow puts/p/pp to work without specifying output device.
  $> = $stderr

  server.process_message(initialize_request)
  server.start
end
